SPB Git

spb/spboucher.ai Public

spboucher.ai — personal website of Simon-Pierre Boucher.

TypeScript 93.4% HTML 5.5% CSS 1%
12.4 KB · 368 lines tsx
Raw Blame History
1/*2  page.tsx3  spboucher.ai Web4  Author: Simon-Pierre Boucher5  Mail: contact@spboucher.ai6*/78import type { Metadata } from "next";9import Image from "next/image";10import Link from "next/link";11import { notFound } from "next/navigation";12import {13  ArrowLeft,14  Award,15  BookOpen,16  Download,17  ExternalLink,18  Github,19  Layers,20  Sparkles,21  Tag,22  Workflow,23} from "lucide-react";2425import { Badge } from "@/components/ui/badge";26import { buttonVariants } from "@/components/ui/button";27import { Card, CardHeader } from "@/components/ui/card";28import { IconTile } from "@/components/icon-tile";29import { Reveal } from "@/components/reveal";30import { SectionHeading } from "@/components/section-heading";31import { getAllProjectLinks, type ProjectLinks } from "@/lib/apps";32import { getDetailIcon } from "@/lib/detail-icons";33import { projectDetails } from "@/lib/project-details";3435interface PageProps {36  params: Promise<{ slug: string }>;37}3839function getProject(slug: string): ProjectLinks | undefined {40  return getAllProjectLinks().find((p) => p.slug === slug);41}4243export function generateStaticParams() {44  return getAllProjectLinks()45    .filter((p) => projectDetails.some((d) => d.slug === p.slug))46    .map((p) => ({ slug: p.slug }));47}4849export async function generateMetadata({50  params,51}: PageProps): Promise<Metadata> {52  const { slug } = await params;53  const project = getProject(slug);54  const detail = projectDetails.find((d) => d.slug === slug);55  if (!project || !detail) return {};56  return {57    title: `${project.name} — ${project.tagline}`,58    description: detail.hero.subheadline,59  };60}6162export default async function ProjectDetailPage({ params }: PageProps) {63  const { slug } = await params;64  const project = getProject(slug);65  const detail = projectDetails.find((d) => d.slug === slug);66  if (!project || !detail) notFound();6768  const statCols =69    detail.stats.length <= 370      ? "sm:grid-cols-3"71      : detail.stats.length === 472        ? "sm:grid-cols-2 lg:grid-cols-4"73        : detail.stats.length === 574          ? "sm:grid-cols-3 lg:grid-cols-5"75          : "sm:grid-cols-3 lg:grid-cols-6";7677  return (78    <div className="mx-auto max-w-5xl px-4 py-16 sm:px-6">79      {/* Back link */}80      <Reveal>81        <Link82          href="/apps"83          className="inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-primary"84        >85          <ArrowLeft className="h-4 w-4" aria-hidden="true" />86          All apps &amp; projects87        </Link>88      </Reveal>8990      {/* Hero */}91      <Reveal className="mt-8">92        <div className="flex flex-col gap-6 sm:flex-row sm:items-start">93          {project.icon ? (94            <Image95              src={project.icon}96              alt={`${project.name} icon`}97              width={96}98              height={96}99              priority100              className="h-24 w-24 shrink-0 rounded-[0.9rem] border border-border shadow-[var(--shadow-soft)]"101            />102          ) : (103            <div104              aria-hidden="true"105              className="flex h-24 w-24 shrink-0 items-center justify-center rounded-[0.9rem] border border-foreground/15 bg-card text-5xl shadow-[var(--shadow-soft)]"106            >107              {project.emoji}108            </div>109          )}110          <div className="min-w-0">111            <div className="flex flex-wrap items-center gap-2">112              <Badge>113                {project.kind === "zyquo"114                  ? "Zyquo macOS Suite"115                  : project.demo116                    ? "Web Platform"117                    : "Open Source"}118              </Badge>119              {project.language && (120                <Badge variant="outline">{project.language}</Badge>121              )}122            </div>123            <h1 className="mt-3 font-display text-3xl font-bold tracking-tight sm:text-4xl">124              {project.name}125            </h1>126            <p className="mt-2 text-lg font-medium text-foreground/85">127              {detail.hero.headline}128            </p>129            <p className="mt-2 max-w-2xl text-sm leading-relaxed text-muted-foreground">130              {detail.hero.subheadline}131            </p>132            <div className="mt-5 flex flex-wrap gap-2">133              <a134                href={project.repo}135                target="_blank"136                rel="noopener noreferrer"137                className={buttonVariants({ variant: "outline", size: "sm" })}138              >139                <Github aria-hidden="true" />140                GitHub Repo141              </a>142              {project.release && (143                <a144                  href={project.release}145                  target="_blank"146                  rel="noopener noreferrer"147                  className={buttonVariants({ variant: "outline", size: "sm" })}148                >149                  <Tag aria-hidden="true" />150                  Release151                </a>152              )}153              {project.demo && (154                <a155                  href={project.demo}156                  target="_blank"157                  rel="noopener noreferrer"158                  className={buttonVariants({ variant: "default", size: "sm" })}159                >160                  <ExternalLink aria-hidden="true" />161                  Visit {project.demo.replace(/^https?:\/\//, "")}162                </a>163              )}164              {project.dmg && (165                <a166                  href={project.dmg}167                  className={buttonVariants({ variant: "default", size: "sm" })}168                >169                  <Download aria-hidden="true" />170                  Download DMG171                </a>172              )}173            </div>174          </div>175        </div>176      </Reveal>177178      {/* Stats band */}179      {detail.stats.length > 0 && (180        <Reveal className="mt-14">181          <div className={`grid grid-cols-2 gap-x-8 gap-y-8 ${statCols}`}>182            {detail.stats.map((stat) => (183              <div key={stat.label} className="border-t-2 border-foreground pt-3">184                <p className="font-display text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">185                  {stat.value}186                </p>187                <p className="mt-1.5 font-mono text-[0.66rem] uppercase leading-relaxed tracking-[0.12em] text-muted-foreground">188                  {stat.label}189                </p>190              </div>191            ))}192          </div>193        </Reveal>194      )}195196      {/* Overview */}197      <section className="mt-14">198        <Reveal>199          <SectionHeading title="Overview" icon={BookOpen} />200        </Reveal>201        <Reveal delay={0.06}>202          <div className="mt-4 max-w-3xl space-y-4">203            {detail.overview.map((paragraph) => (204              <p205                key={paragraph.slice(0, 48)}206                className="text-sm leading-relaxed text-muted-foreground sm:text-[0.95rem]"207              >208                {paragraph}209              </p>210            ))}211          </div>212        </Reveal>213      </section>214215      {/* Features */}216      {detail.features.length > 0 && (217        <section className="mt-14">218          <Reveal>219            <SectionHeading title="Key Features" icon={Sparkles} />220          </Reveal>221          <div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">222            {detail.features.map((feature, i) => (223              <Reveal key={feature.title} delay={i * 0.05} className="h-full">224                <Card className="h-full hover:-translate-y-1 hover:border-primary/25 hover:shadow-[var(--shadow-lift)]">225                  <CardHeader>226                    <IconTile icon={getDetailIcon(feature.icon)} size="sm" />227                    <h3 className="pt-2 text-sm font-semibold tracking-tight">228                      {feature.title}229                    </h3>230                    <p className="text-xs leading-relaxed text-muted-foreground">231                      {feature.description}232                    </p>233                  </CardHeader>234                </Card>235              </Reveal>236            ))}237          </div>238        </section>239      )}240241      {/* How it works */}242      {detail.architecture.length > 0 && (243        <section className="mt-14">244          <Reveal>245            <SectionHeading title="How It Works" icon={Workflow} />246          </Reveal>247          <ol className="mt-6 space-y-4">248            {detail.architecture.map((step, i) => (249              <Reveal key={step.title} delay={i * 0.06}>250                <li className="flex items-start gap-4">251                  <span252                    aria-hidden="true"253                    className="mt-1 pt-0.5 font-mono text-sm font-semibold text-primary"254                  >255                    {String(i + 1).padStart(2, "0")}256                  </span>257                  <div className="min-w-0 pt-1">258                    <h3 className="text-sm font-semibold tracking-tight">259                      {step.title}260                    </h3>261                    <p className="mt-1 max-w-2xl text-sm leading-relaxed text-muted-foreground">262                      {step.description}263                    </p>264                  </div>265                </li>266              </Reveal>267            ))}268          </ol>269        </section>270      )}271272      {/* Tech stack */}273      {detail.techStack.length > 0 && (274        <section className="mt-14">275          <Reveal>276            <SectionHeading title="Tech Stack" icon={Layers} />277          </Reveal>278          <div className="mt-6 space-y-5">279            {detail.techStack.map((group, i) => (280              <Reveal key={group.category} delay={i * 0.05}>281                <div>282                  <p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">283                    {group.category}284                  </p>285                  <div className="mt-2 flex flex-wrap gap-2">286                    {group.items.map((item) => (287                      <Badge key={item} variant="secondary">288                        {item}289                      </Badge>290                    ))}291                  </div>292                </div>293              </Reveal>294            ))}295          </div>296        </section>297      )}298299      {/* Highlights */}300      {detail.highlights.length > 0 && (301        <section className="mt-14">302          <Reveal>303            <SectionHeading title="Highlights" icon={Award} />304          </Reveal>305          <ul className="mt-6 grid gap-3 sm:grid-cols-2">306            {detail.highlights.map((highlight, i) => (307              <Reveal key={highlight} delay={i * 0.05} className="h-full">308                <li className="flex h-full items-start gap-3 border border-border bg-card px-5 py-4 shadow-[var(--shadow-soft)]">309                  <Sparkles310                    className="mt-0.5 h-4 w-4 shrink-0 text-primary"311                    aria-hidden="true"312                  />313                  <span className="text-sm leading-relaxed text-foreground/85">314                    {highlight}315                  </span>316                </li>317              </Reveal>318            ))}319          </ul>320        </section>321      )}322323      {/* CTA */}324      <Reveal className="mt-16">325        <div className="border-y-2 border-foreground bg-card/50 p-8 text-center sm:p-10">326          <h2 className="font-display text-xl font-semibold tracking-tight">327            Explore {project.name}328          </h2>329          <p className="mx-auto mt-2 max-w-xl text-sm text-muted-foreground">330            {project.tagline} — the full source is on GitHub.331          </p>332          <div className="mt-5 flex flex-wrap justify-center gap-2">333            <a334              href={project.repo}335              target="_blank"336              rel="noopener noreferrer"337              className={buttonVariants({ variant: "default", size: "sm" })}338            >339              <Github aria-hidden="true" />340              View source341            </a>342            {project.demo && (343              <a344                href={project.demo}345                target="_blank"346                rel="noopener noreferrer"347                className={buttonVariants({ variant: "outline", size: "sm" })}348              >349                <ExternalLink aria-hidden="true" />350                Open the live site351              </a>352            )}353            {project.dmg && (354              <a355                href={project.dmg}356                className={buttonVariants({ variant: "outline", size: "sm" })}357              >358                <Download aria-hidden="true" />359                Download DMG360              </a>361            )}362          </div>363        </div>364      </Reveal>365    </div>366  );367}368